feat(resizable): DLT-2097 add DtResizable panel layout component#1162
feat(resizable): DLT-2097 add DtResizable panel layout component#1162Joshua Hynes (hynes-dialpad) merged 63 commits intostagingfrom
Conversation
Port resizable panel system from beacon-app as DtResizable. Three components (DtResizable, DtResizablePanel, DtResizableHandle), 10 composables with three-layer architecture (pure engine, state management, orchestration), drag interaction with shadow DOM for 60fps, and 63 passing tests across 4 Storybook stories. V1 of 7 vertical slices — core layout engine only.
… control (V2) - Re-add panels and spaceAllocationStrategy props to dt_resizable.vue - Wire spaceAllocationStrategy through scoped slot and defineExpose - Add 37 V2 tests covering constraint enforcement, auto-collapse rules, space allocation strategies, and programmatic control API surface - Add 3 Storybook stories: Constraints, Collapsible, Programmatic - Register V2 stories in dt_resizable.stories.js index
…ce (V3) - Add ResizableStorageAdapter interface and ResizableStoragePanelData type - Refactor useResizableStorage with localStorageAdapter(key) factory - Add :storage prop for custom adapter injection (Pinia, Vuex, IndexedDB) - Wire storageAdapter through useResizableGroup options - Custom adapter takes precedence over storageKey when both provided - 28 tests: save/load cycle, validation, corrupted data, adapter precedence - 2 stories: localStorage persistence demo, custom adapter example
…ed composable (V6)
…and package exports (V7) - Register dt_resizable, dt_resizable_panel, dt_resizable_handle in components_list.js - Export resizable components and common/composables from package index - Add Resizable entry to site-nav.json sidebar navigation - Create VuePress docs page with usage, constraints, collapsible, persistence, peek overlay, keyboard accessibility, programmatic control, and full API tables
- Extract DEFAULT_PANEL_SIZE and MIN_PANEL_SIZE_PX constants (was '50p' hardcoded in 9 locations, magic number 10 in 3) - Scope useDOMCache MutationObserver to container via observeRoot option (was observing document.body globally, invalidating on all DOM changes) - Add ref-counted cleanup for announcement DOM element (was never removed) - Remove void statements in keyboard resize callback (was suppressing unused-var warnings for no-op) - Remove dead useResizableGroupSetup export from barrel
…e dead code
- Add clampSize() and clampToTier() to constraintResolver.ts as the
single source of truth for constraint clamping. Replace 4 duplicate
implementations across useResizablePanelState, useResizableCalculations,
useResizablePanelControls, and computeLayout.
- Replace hardcoded SIZE_TOKENS map with runtime CSS custom property
resolution (reads --dt-size-{token} from getComputedStyle). Falls
back to static map in test/SSR environments. Percentages now parsed
dynamically from the 'p' suffix pattern.
- Remove dead useResizableGroupSetup (404 lines) — replaced by
useResizableGroup.ts in V1, never imported by any component.
useResizableCore.ts reduced from 623 to 195 lines.
- Fix handle class prop to accept String, Object, Array (was String-only) - Replace hardcoded 2px in focus-visible with --dt-size-200/300 tokens - Add comment explaining panelsChanged custom comparator rationale - Note storageKey/storage capture-at-mount limitation
Add missing story imports and exports for ResizableKeyboard, ResizablePeekHover, ResizablePeekButton, and ResizableOffset templates in dt_resizable.stories.js.
…t.each Replace repetitive it() blocks with parameterized it.each tables in KEYBOARD_INCREMENTS and offsetDirection test suites.
…announcements
- Add ResizableEditModeMessages interface with optional fields for all
edit mode announcements (editModeActivated, editModeDeactivated,
editModeNoHandles, panelsReset, allPanelsReset) with English defaults
- Add ResizableKeyboardMessages interface with resizeAnnouncement template
using {beforeId}, {afterId}, {beforePx}, {afterPx}, {action},
{incrementType} placeholders
- Add ariaLabel prop to DtResizableHandle for i18n override of the
default computed aria-label
- Add messages prop to DtResizable, wired through provide/inject to
both useResizableEditMode and useResizableKeyboard
- Add RESIZABLE_MESSAGES_KEY injection key for cross-component messaging
…tcuts and aria-description - Remove createInstructions() that injected a hidden DOM element with hardcoded English instructions linked via aria-describedby - Add aria-keyshortcuts attribute to handle: "Control+e" when inactive, full shortcut list when in edit mode - Add aria-description attribute with contextual hint: edit mode entry prompt when inactive, full controls summary when active - Add editModeDescription and editModeActiveDescription to ResizableEditModeMessages interface for i18n support - Update tests to verify new ARIA attributes instead of DOM element - Remove stale dt-resize-instructions cleanup from test afterEach hooks
Wiz Scan Summary
To detect these findings earlier in the dev lifecycle, try using Wiz Code VS Code Extension. |
…compat All other Dialtone component barrels use index.js (not index.ts). The TS compiler cannot resolve .vue module imports from .ts files without vue shim declarations. Renaming to .js matches the established convention and fixes the build.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 22b40c7af6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
The typedoc build-functions target generates "Date and Time" docs from common/*/index.js. The new common/composables/ directory (useDOMCache) is not part of that scope. Excluding it prevents potential resolution issues with .ts entry points in CI.
…ical stories The Default and Vertical stories lacked docs.source.code overrides, causing Storybook to auto-generate incorrect code showing the wrapper component tag (dt_resizable) instead of the actual template with DtResizablePanel and DtResizableHandle children.
…ult direction - Convert markdown tables to HTML tables for MDX rendering compatibility - Remove unnecessary direction="row" from code examples (it's the default)
|
Please add either the |
…over - Add d-w100p class to all story panel content divs so they fill width - Add styles for __indicator element (was unstyled in template) - Move hover/active background-color to __indicator with 150ms transition
Replace physical properties (top/left/right/bottom) with logical properties (inset-inline-start/end, inset-block, inline-size). Container sets writing-mode: vertical-lr for column direction, so all positioning rotates automatically. - Panel: inset-block: 0 + insetInlineStart/End (was top/bottom: 0 + left/right) - Handle: inset-block + inline-size (was separate row/column blocks) - Hit area: inset shorthand (was 4 physical properties) - Indicator: inset: 0 (was duplicate row/column blocks) - Panel content: writing-mode: horizontal-tb reset for normal text - Removed direction-switching JS in handle positioning
Define --dt-resizable-handle-color-surface on the handle root, defaulting to --dt-color-surface-info-strong. All hover, active, and focus-visible styles reference the component variable instead of the raw token — single place to update for theming.
- Add panelMap computed (Map<id, PanelState>) to useResizableGroup for O(1) lookups — replaces 14 .find() calls across 4 files - Replace setTimeout ARIA debounce with computed properties (ariaValueNow/Min/Max derive directly from panel state) - Replace JSON.stringify watch with deep:true on panelConfig computed - Remove unused direction param from useResizeHandling - Fix silent catch in useResizableOffset
- Migrate from mouse+touch dual listeners to Pointer Events (pointerdown/pointermove/pointerup/pointercancel). Halves the event listener code in useResizableDrag. Added touch-action: none to handle CSS for proper pointer capture on touch devices. - Replace array-index handle registration with DOM-order resolution. Handle now queries its sibling index at render time instead of relying on mount-order array position. Stable under dynamic mount/unmount/reorder. - Remove getEventPosition from useResizeHandling (dead after pointer events migration — drag reads clientX/Y directly).
- computeLayout now treats locked panels as fixed (won't resize during redistribution) - Collapsing a panel clears manualTargetRatio on siblings so they fill the freed space instead of maintaining their drag ratio - Replace ensureAtLeastOneUnlocked direct mutation with findPanelToForceUnlock that returns the ID for the caller to update via savedState
Constraints (userMinSize/userMaxSize) handle the same use cases. Removes locked/lockPanel/unlockPanel/findPanelToForceUnlock from types, composables, component expose, tests, stories, and docs.
Offset is now a DtResizable prop instead of per-handle. The parent measures the offset element once and provides both handle styles (inset-block-start) and panel content styles (padding-block-start) to all children via context. - offset-element: CSS selector for fixed header/toolbar - offset-amount: explicit pixel value (overrides element measurement) - offset-direction: 'start' (default), 'end', or 'both' Handles and panels share the same offset automatically — no manual panel padding needed by consumers.
Replace || true hack with proper pointerdown trigger and JSDOM clientWidth mock so the drag actually activates in the test env.
|
Fixed — the keyboard system was completely rewritten to use the W3C separator pattern (
The three-panel keyboard issue should be resolved. Please re-test on the deploy preview. |
|
Removed — the peek feature was cut from this PR in The core component now focuses on: resize, collapse/expand, keyboard a11y, persistence, and constraints. Peek overlay can be built as a Beacon extension later if needed. |
…zable-component # Conflicts: # pnpm-lock.yaml
|
Joshua Hynes (@hynes-dialpad) Great updates. I've gone through and see no further issues. I've stress-tested the interaction nuances and it feels bulletproof and snappy. Deferring approval to Brad Paugh (@braddialpad). |
Brad Paugh (braddialpad)
left a comment
There was a problem hiding this comment.
Looks much cleaner, thanks for the changes.
Approving, with a couple cleanup items
| export function allocateSpaceOnPanelOpen( | ||
| newPanelSize: number, | ||
| allPanels: ResizablePanelState[], | ||
| strategy: SpaceAllocationStrategy = 'proportional' | ||
| ): Map<string, number> { | ||
| const newSizes = new Map<string, number>(); | ||
| const availablePanels = allPanels.filter(p => !p.collapsed); | ||
|
|
||
| if (availablePanels.length === 0 || newPanelSize <= 0) { | ||
| allPanels.forEach(p => newSizes.set(p.id, p.pixelSize)); | ||
| return newSizes; | ||
| } | ||
|
|
||
| if (strategy === 'preserve-manual') { | ||
| const donors = availablePanels.filter(p => p.manualTargetSize === undefined); | ||
| const manualPanels = availablePanels.filter(p => p.manualTargetSize !== undefined); | ||
|
|
||
| if (donors.length === 0) { | ||
| return allocateSpaceOnPanelOpen(newPanelSize, allPanels, 'proportional'); | ||
| } | ||
|
|
||
| const totalDonorSpace = donors.reduce((sum, p) => sum + p.pixelSize, 0); | ||
| if (totalDonorSpace < newPanelSize) { | ||
| console.warn( | ||
| `[resizable] preserve-manual: Donor panels only have ${totalDonorSpace}px, ` + | ||
| `need ${newPanelSize}px. Falling back to proportional.` | ||
| ); | ||
| return allocateSpaceOnPanelOpen(newPanelSize, allPanels, 'proportional'); | ||
| } | ||
|
|
||
| applyProportionalAllocation(donors, newPanelSize, newSizes); | ||
| manualPanels.forEach(p => newSizes.set(p.id, p.pixelSize)); | ||
| includeUnchangedPanels(allPanels, newSizes); | ||
| } else { | ||
| // Default: proportional | ||
| applyProportionalAllocation(availablePanels, newPanelSize, newSizes); | ||
| includeUnchangedPanels(allPanels, newSizes); | ||
| } | ||
|
|
||
| return newSizes; | ||
| } |
There was a problem hiding this comment.
After the recent changes, I believe this is no longer used.
There was a problem hiding this comment.
Removed in 9e6f7fe — deleted allocateSpaceOnPanelOpen, applyProportionalAllocation, includeUnchangedPanels (~80 lines), the SpaceAllocationStrategy type, barrel export, and associated tests.
| function commitPanelSize (panelId, pixels) { | ||
| const rounded = Math.round(pixels); | ||
| const cSize = group.containerSize.value; | ||
| const ratio = cSize > 0 ? rounded / cSize : undefined; | ||
| group.updateSavedPanel(panelId, { pixelSize: rounded, manualTargetRatio: ratio }); | ||
| } |
There was a problem hiding this comment.
function duplicated in useResizablePanelControls.ts. Pretty minor, but could cause one to get out of sync if changes are ever made to it.
There was a problem hiding this comment.
Fixed in 9e6f7fe — removed the duplicate from resizable.vue. The single definition lives in useResizablePanelControls, now exported from its return object. resizable.vue destructures it: const { commitPanelSize, ... } = useResizablePanelControls({...}).
| function commitPanelSize(panelId: string, pixels: number): void { | ||
| const rounded = Math.round(pixels); | ||
| const cSize = containerSize.value; | ||
| const ratio = cSize > 0 ? rounded / cSize : undefined; | ||
| updateSavedPanel(panelId, { pixelSize: rounded, manualTargetRatio: ratio }); | ||
| } |
There was a problem hiding this comment.
duplicate I earlier mentioned.
There was a problem hiding this comment.
Fixed — single definition, no duplicate. See above.
| } | ||
| } | ||
|
|
||
| function applyResize( |
There was a problem hiding this comment.
A fairly minor issue here, I'll leave it up to you if you think it's worth fixing. I'll paste Claude's summary of it because I think it explains it clearly.
Drag path (useResizableDrag.ts):
- During move (line 311–315): writes inline styles to DOM only. Reactive state untouched.
- On mouseup, commitDrag (line 330–332): clears inline styles first, then calls onDragEnd (line 339) which commits to reactive state.
Two clearly separated moments. Vue never sees competing style sources.
Keyboard path (useResizableKeyboard.ts), inside applyResize (line 167–203):
- Lines 180–195: writes inline styles to DOM for immediate visual feedback.
- Lines 197–202: immediately calls onResize(...) in the same function, which commits to reactive state.
Both happen synchronously in the same call. Vue then schedules a re-render which will overwrite the inline styles with the computed layout values in the next frame.
Why it usually works fine: Vue's async re-render is fast enough that the inline styles get replaced cleanly before the next paint in most cases. The visual result is correct.
The actual risk: if anything reads DOM positions (e.g. element.style.insetInlineStart) immediately after a keyboard resize — during the window between applyResize returning and Vue's re-render completing — it would get the keyboard-written inline value,
not the layout-computed value. Any code that introspects element positions for further calculations could act on stale data in that window.The fix would mirror the drag approach: in applyResize, after calling onResize, clear the inline styles immediately rather than leaving Vue to do it a frame later. That way the DOM is always exclusively owned by one system at a time.
It's low severity — it doesn't cause visual glitches in normal use — but the inconsistency is a latent bug surface if the component grows.
There was a problem hiding this comment.
Fixed in 9e6f7fe — applyResize now clears inline styles immediately after calling onResize(), matching the drag path's discipline. Added a clearInlineStyles() helper that zeroes out insetInlineStart, insetInlineEnd, and inlineSize on both panels and the handle. Vue exclusively owns the DOM from the moment onResize returns.
- Remove dead allocateSpaceOnPanelOpen + helpers (~80 lines) - Deduplicate commitPanelSize: single definition in composable, resizable.vue destructures from return instead of defining its own - Fix keyboard shadow→commit discipline: clear inline styles after onResize so Vue exclusively owns the DOM (matches drag pattern)
|
✔️ Deploy previews ready! |
Mentioned he was deferring to me
# [8.78.0](dialtone-css/v8.77.0...dialtone-css/v8.78.0) (2026-04-07) ### Features * **Resizable:** DLT-2097 add DtResizable panel layout component ([#1162](#1162)) ([c6bd3bc](c6bd3bc))
# [3.219.0](dialtone-vue/v3.218.5...dialtone-vue/v3.219.0) (2026-04-07) ### Features * **Resizable:** DLT-2097 add DtResizable panel layout component ([#1162](#1162)) ([c6bd3bc](c6bd3bc))
# [9.178.0](dialtone/v9.177.2...dialtone/v9.178.0) (2026-04-07) ### Features * **Resizable:** DLT-2097 add DtResizable panel layout component ([#1162](#1162)) ([c6bd3bc](c6bd3bc))
Obligatory GIF (super important!)
🛠️ Type Of Change
📖 Jira Ticket
DLT-2097
Stories:
📖 Description
New
DtResizablecomponent system — a resizable panel layout ported from Beacon and genericized for Dialtone. Three components, 10 composables, 160 tests across 8 test files, 7 Storybook stories, and a full VuePress docs page.Components
Key Features
storageKeyfor localStorage,:storageprop for custom adapters (Pinia/Vuex/API)offsetElement/offsetAmountprops. Handles and panel content offset automatically from fixed headers or toolbars.messagesprop with{placeholder}templatesArchitecture
computeLayout.ts) — single source of truth for all panel positions. Zero Vue dependencies.User Action → savedState → computeLayout reruns → Vue rendersuseResizableDrag.ts) — inline styles during pointer move for 60fps, commits to savedState on pointer upconstraintResolver.ts) — 3-tier system (user → system → collapse) applied within computeLayoutpackages/dialtone-css/lib/build/less/components/resizable.less📦 Cross-Package Impact
resizable.lesscomponent styles📄 Documentation Artifacts
packages/dialtone-vue/components/resizable/docs/components/resizable.mdwith full API tablespackages/dialtone-vue/index.js💡 Context
This is an FY27 Q1 Key Result under the "Dialtone Next and Beacon Alignment" initiative (DLT-2979). The resizable panel system was originally built for Beacon's sidebar/main/peek layout. This PR ports it to Dialtone as a first-class component, genericized for any resizable panel layout use case.
Reviewer feedback addressed
📝 Checklist
For all PRs: